Skip to content

fix(cli): guard convention/label-case on a localized label - #16280

Merged
os-litant merged 5 commits into
mainfrom
claude/issue-15880-lint-label-case-localized
Sep 6, 2026
Merged

fix(cli): guard convention/label-case on a localized label#16280
os-litant merged 5 commits into
mainfrom
claude/issue-15880-lint-label-case-localized

Conversation

@os-litant

@os-litant os-litant commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15880

os lint crashed with a bare TypeError: Cannot read properties of undefined (reading 'toUpperCase') on a config that ObjectStackDefinitionSchema parses clean.

checkLabelCase in packages/cli/src/commands/lint.ts indexed its argument (label[0].toUpperCase()) on a parameter annotated string, while every call site reaches it through any-typed config walking. I18nLabelSchema is z.union([z.string(), InlineLocaleMapSchema]), so on the inline locale map form label[0] is undefined. The throw escaped lintConfig into the command's catch-all: an author who localized an app label or a list-view label could not lint the project at all, on either face, and the message named no rule, no path and no remedy.

The rule now returns early unless typeof label === 'string'. That is the whole change.

Scope: the guard, deliberately not a locale-aware case check

Per triage, the deliverable is that os lint stops crashing — not a decision about how a localized label should be case-checked. Resolving the map and case-checking one entry would decide which locale entry is authoritative for a lint verdict, which is a product call this PR does not make. So the rule says nothing about a localized label, and the tests assert the absence of a convention/label-case issue, so that widening the rule later has to rewrite those assertions on purpose rather than satisfy them silently.

How many call sites reach the rule

lintConfig walks five collections — objects, views, apps, flows, agents — and calls checkLabelCase from 4 sites, reaching 5 authoring paths (getViewLabel resolves to either the list or the listViews.* path):

call site authoring path governing schema accepts the map
lint.ts:202 objects[].label object.zod.ts:1621z.string().optional() no
lint.ts:229 objects[].fields.*.label field.zod.ts:933z.string().optional() no
lint.ts:254 views[].list.label / views[].listViews.*.label view.zod.ts:1819I18nLabelSchema.optional() yes
lint.ts:268 apps[].label app.zod.ts:1291I18nLabelSchema yes

The sweep filed on the card found exactly two hits because its fixture reached exactly two. The class is the set of call sites, not the set of sweep hits — which is why views[].listViews.*.label is pinned here even though no sweep reached it.

Reachability observation (measured, not fixed here)

I18nLabelSchema is imported by nine ui/ schemas — app, view, page, dashboard, chart, report, action, bulk-action, component — but lintConfig never walks pages, dashboards, charts, reports, actions, bulkActions or components. So convention/label-case never reaches those labels at all, before or after this change. The rule's coverage is narrower than the schema surface it nominally governs. Reported for triage, not touched here.

Both faces, before and after

Fixture: an app with label: { en: 'Todos', 'zh-CN': ... } plus a minimal manifest, driven through the built CLI.

Before (main's lint.ts restored over the fix, CLI rebuilt):

os lint --score           EXIT=1    ✗ Cannot read properties of undefined (reading 'toUpperCase')
os lint --score --json    EXIT=1    {"error":"Cannot read properties of undefined (reading 'toUpperCase')","conversions":[]}

After:

os lint --score           EXIT=0    1 warning(s) · Metadata quality: 97/100  (A)
os lint --score --json    EXIT=0    "passed": true, "score": 97, "grade": "A", no `error` key

What it now says, and that it is true: the linter finishes and reports exactly one warning — protocol/missing-engines-range at manifest.engines.protocol, which is a real property of the fixture and unrelated to the label — with 0 errors, 0 schema and grade A. It emits no convention/label-case issue for the localized label, and it does not report that label as missing either. A guard that merely silenced the crash while emitting a spurious required/label is pinned against explicitly.

Behaviour on a plain string label does not move

Pinned per carrier rather than asserted: all five carriers keep the same warning, message, fix value and path. The ablation below is what proves it — under the mutation those rows stay green, i.e. this branch and main agree on every string carrier.

Ablation

Falsification conditions named and directions predicted before running. Mutation = main's lint.ts restored over the fix; proven on disk by blob hash both ways, restored under an EXIT INT TERM trap with absolute paths, restore proven by observed state.

falsifier predicted observed
os lint --score red red — exit 1, bare TypeError
os lint --score --json red red — {"error": ..., "conversions": []}
localized rows, 3 map-accepting carriers (13 rows) red red — 13 red
plain-string carrier rows (8 rows) green green — 8 green
schema-valid fixture block (5 rows) green green — independent of lint.ts
scorer reaches a verdict red red — lintError set
score-lint-crash.test.ts green green — mocks the linter

Mutation proof: source blob d6c14bb15f16... (main) with the guard marker absent, and the marker confirmed absent in dist/ so the CLI ran mutated code. Restore proof: blob back to 611e6613c33b..., git diff HEAD empty, marker present in source and in dist/ again — observed state, not an exit code.

A repair to the pins themselves

The schema-valid block called normalizeStackInput(stack).stack, but normalizeStackInput returns the normalized stack directly, not a wrapper. .stack was undefined, so all four rows were parsing undefined and were red. Its CONTROL row — "a number label does NOT parse, so the parse check discriminates" — was passing for the wrong reason, since undefined does not parse either. The block that exists to prove the localized fixtures are supported authoring input was measuring nothing. Dropping .stack makes the four positives parse clean and leaves the control rejecting, so the control discriminates on the label for the first time.

For triage: a schema asymmetry this PR deliberately did not touch

Measured here, not changed, per the binding instruction not to unify the two declarations in this PR:

  • objects[].label (packages/spec/src/data/object.zod.ts:1621) is z.string().optional()
  • objects[].fields.*.label (packages/spec/src/data/field.zod.ts:933, FieldSchema) is z.string().optional()
  • apps[].label (packages/spec/src/ui/app.zod.ts:1291, AppSchema) is I18nLabelSchema
  • views[].list.label (packages/spec/src/ui/view.zod.ts:1819) is I18nLabelSchema.optional()

So an app label may be localized and an object label may not. Narrowing or widening either is a published-declaration change and needs a human floor. Flagged for the dispatching seat to file; not filed from here.

Two anchors above were corrected after review. They were inherited from triage and were pointing at the wrong member; both were re-measured against the tree at this PR's merge base and the citations now name the schema each line actually belongs to.

claim as first written what that line actually is corrected anchor
app.zod.ts:300 is AppSchema.label BaseNavItemSchema.label (BaseNavItemSchema opens at app.zod.ts:295) app.zod.ts:1291
field.zod.ts:299 is the field base label, z.string() SelectOptionSchema.label (SelectOptionSchema opens at field.zod.ts:288) field.zod.ts:933, and it is z.string().optional(), not z.string()

Both replacements are still I18nLabelSchema and still a plain string respectively, so every conclusion drawn above is unchanged — the accepts-the-map column, the 4-call-site/5-authoring-path count and the asymmetry itself were all driven independently of these line numbers. The one substantive correction is .optional(): with it, both object-side labels are z.string().optional(), so there is no required-vs-optional asymmetry between them. The only asymmetry is plain string vs I18nLabelSchema, which is what #16282 records. The other two anchors (object.zod.ts:1621, view.zod.ts:1819) were re-measured too and are correct as written.

Verification

At head c8d31a3fe0e — the implementation

  • pnpm --filter '@objectstack/cli^...' buildVERDICT command-exit 0
  • pnpm --filter @objectstack/cli exec vitest run over 7 lint/score files — VERDICT command-exit 0, 7 files / 68 tests passed
  • pnpm --filter @objectstack/cli typecheckVERDICT command-exit 0; check:test-typecheck: OK. All three edited files confirmed present in the tsconfig.test.json program via --listFiles, and zero errors in them (the ledger's 3 files / 28 errors are pre-existing and untouched)
  • Gate roster: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackReconciliation — 57 famil(ies). Targeted subset run locally, all exit 0: check-changeset-no-major, check-empty-changeset, check:nul-bytes, check:test-source-alias, check-changeset-fixed, check:objectui-changeset, check:changeset-gate-self-tests. The farm is CI's to run in full.

At head 764c049adb8 — the changeset level

The first head graded @objectstack/cli patch while declaring clause ②, and Check Changeset refused it. The level is now minor.

That axis is only readable from a pull_request payload, so it is driven here with --event carrying this PR's live label set (documentation, size/m, tests, tooling, needs:contract-review) and its body. Same payload and same --base origin/main in both runs — only the tree differs, by the one word:

CONTROL  --head c8d31a3fe0e  (patch)   EXIT=1
  This PR declares clause-2 YES and grades a package it grew `patch`.
    - @objectstack/cli: patch   ← this PR moves @objectstack/cli's packages/*/src/**

HEAD     (764c049adb8, minor)     EXIT=0
  LEVEL AXIS: this PR declares clause-2 `yes`, and no package whose
  `packages/*/src/**` it moves is graded `patch`.
    carrier: `needs:contract-review` IS on this PR

The control is what makes the green informative: it reproduces the exact CI refusal from the same payload, so the pass is a read axis rather than an unread one.

⚠️ For anyone re-running this: the bare node scripts/check-changeset-no-major.mjs --base origin/main form prints LEVEL AXIS: NOT MEASURED and exits 0 on both trees. It cannot distinguish them, and reading that exit code as a pass is what let the wrong level reach CI. Drive it with --event.

A falsification condition in the test header was also replaced, because it did not falsify. The NO MOVE property offered a label != null guard that changes the empty-string case as a third way to swallow strings. It is neither: on a lowercase string label != null is true, so the row still reports; on '' both spellings reach the falsy label && test and return null. Measured as a pair, mutating lint.ts on disk each way (blob hash before/after, restored under an EXIT INT TERM trap, restore proven by git diff HEAD empty):

guard put in place of the real one NO MOVE lowercase rows that went red falsifies NO MOVE?
if (label == null) return null; — as the header claimed 0 of 5 no
if (typeof label === 'string') return null; — the replacement 5 of 5 yes

The header now names the inverted guard, which produces the observable it claims: every lowercase row stops reporting.

Also at this head, all exit 0: check:nul-bytes, check:changeset-gate-self-tests, check:empty-changeset, check:objectui-changeset, check:changeset-fixed, check:doc-authoring, check-comment-mask-adoption, check-adr-0087-registration --base origin/main, plus the @objectstack/cli dependency-closure build and the package's own build, @objectstack/cli typecheck (check:test-typecheck: OK, the 3-file / 28-error debt ledger unchanged), and the full @objectstack/cli vitest suite — all 270 test files green at this head, measured in two passes: 263 files in the first (3194 passed | 6 expected fail | 31 skipped), and the remaining 7 in a second pass (7 passed, 31 tests). Those 7 drive the PUBLISHED entry and need packages/cli/dist, which the first pass lacked because it had built only the dependency closure (@objectstack/cli^..., which excludes the package itself); they reported packages/cli is not built — a missing prerequisite, not a finding — and pass once the package's own build exists.

Changeset: minor on @objectstack/cli. A bug fix by shape, but the PR declares clause ② and the conformance limb fires — the JSON face moves from {"error": ...} / exit 1 to {"passed": true, ...} / exit 0 on an input class ObjectStackDefinitionSchema parses clean — and a declared widening of a published surface takes at least minor (maintainer ruling, 2026-09-04 decision batch #35).


Generated by Claude Code

`checkLabelCase` indexed its argument (`label[0].toUpperCase()`) on a
parameter annotated `string`, while every call site reaches it through
`any`-typed config walking and `I18nLabelSchema` is
`z.union([z.string(), InlineLocaleMapSchema])`. On the map form `label[0]`
is `undefined`, so the rule threw a `TypeError` that escaped `lintConfig`
into the command's catch-all: every `os lint` face exited 1 with
`Cannot read properties of undefined (reading 'toUpperCase')`, naming no
rule, no path and no remedy, on input `ObjectStackDefinitionSchema` parses
clean.

The rule now returns early unless `typeof label === 'string'`. The string
branch is byte-identical, pinned per carrier. It deliberately says nothing
about a localized label rather than resolving the map: picking which locale
entry a case verdict is taken against is a product call, not a lint call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
The four rows in "the localized fixtures are schema-VALID" called
`normalizeStackInput(stack).stack`, but `normalizeStackInput` returns the
normalized stack itself, not a `{ stack }` wrapper — so `.stack` was
`undefined` and every row was parsing `undefined`, not the fixture. All four
were red.

Worse, the CONTROL row was passing for the wrong reason: it asserts a number
label does NOT parse, and `undefined` does not parse either, so it went green
without ever discriminating on the label. The block that exists to prove the
localized fixtures are supported authoring input was measuring nothing.

Drop the `.stack`. The four positives now parse clean and the control still
rejects, so the control discriminates on the label for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 3 documentable anchor(s).

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2756e07d10f21170e6121ee9125d3c83547ef1fc.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2756e07d10f21170e6121ee9125d3c83547ef1fcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 349c8bd764ade8bba6f873bcf6ae1955459d143d — the merge of head 764c049adb81db5d44ac646890a09d21b4e6301f into base 2756e07d10f21170e6121ee9125d3c83547ef1fc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 349c8bd764ade8bba6f873bcf6ae1955459d143d && git checkout 349c8bd764ade8bba6f873bcf6ae1955459d143d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2756e07d10f21170e6121ee9125d3c83547ef1fc 764c049adb81db5d44ac646890a09d21b4e6301f && git checkout -B drift-repro 2756e07d10f21170e6121ee9125d3c83547ef1fc && git merge --no-ff 764c049adb81db5d44ac646890a09d21b4e6301f

node scripts/docs-audit/affected-docs.mjs --json 2756e07d10f21170e6121ee9125d3c83547ef1fc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2756e07d10f21170e6121ee9125d3c83547ef1fc → pass the list as
args.docs, on the commit named under Which tree this was computed on.

`Check Changeset`'s level axis refuses a PR that declares clause ② and
grades a package it grew `patch`. This PR declares clause ② — the
`needs:contract-review` carrier is on it — and the conformance limb fires:
`convention/label-case`'s JSON face moves from `{"error": ...}` / exit 1 to
`{"passed": true, ...}` / exit 0 on an input class
`ObjectStackDefinitionSchema` parses clean. `minor` is the grade that
declaration implies — a purely additive widening of a published package's
public surface takes at least `minor` (maintainer ruling, 2026-09-04
decision batch #35), and the commit type may raise a bump but never lower
it. The two declarations now agree inside one PR.

Also replaces a falsification condition in the NO MOVE header that did not
falsify. It offered `a label != null guard that changes the empty-string
case` as a third way to swallow strings; it is neither. On a lowercase
string `label != null` is true, so the row still reports and NO MOVE stays
green; on `''` both spellings reach the falsy `label &&` test and return
null, so the empty-string case does not move either. Replaced with the
guard inverted to `typeof label === 'string'`, which does make every
lowercase row below stop reporting — the observable the header claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

Copy link
Copy Markdown
Collaborator Author

Landing provenance — domain:cli execution PM seat (#6024)

Contract review of record — CHANGES REQUESTED, now discharged

At-tier, isolated, adversarial; brief carried the card, its triage bound and the PR only. ⭐ Tier verified before adoption: 107 of 107 harness-stamped model fields claude-fable-5-1, zero fallback. Verdict adopted verbatim.

B1 (blocking): Check Changeset was RED at the reviewed head. Verdict:

⛔ This PR declares clause-② YES and grades a package it grew patch. … The two declarations disagree, inside one PR: · carrier: needs:contract-review IS on this PR · declaration line: the PR body carries no Clause-②: line

⚠️ This seat caused the red, and it was right that it appeared. Hanging needs:contract-review at 11:31Z fired a labeled run, and Check Changeset reads the label set — so the carrier is what made the gate evaluate the level axis at all. No code regressed; the PR was already internally inconsistent and the label made it visible. ⇒ a check population is only valid for the label state it ran under, and this seat had reported "33/33 green" minutes before its own write changed that. Order corrected: labels first, let the re-run settle, verify, then land.

Fixed by route 1, one word: "@objectstack/cli": minor. ⛔ The gate's other route — regrade clause-② to no and drop the carrier — was refused, because lanes/spec.md:19 puts any card changing accept/reject behaviour on the semantic surface however small, and the yes is already history on the card thread.

Driven with a control, which is what makes the green mean anything: same event.json (live label set), same --base, only the tree differing by the one word. Old head c8d31a3fe0eexit 1, reproducing the exact CI refusal. New head → exit 0, ✓ LEVEL AXIS: this PR declares clause-② yes, and no package whose packages/*/src/** it moves is graded patch.

⭐ Why this was catchable before push, and the rule this seat is taking from it

The implementer originally ran check-changeset-no-major.mjs --base origin/main, which printed LEVEL AXIS: NOT MEASURED, and quoted it honestly. Measured now: that bare form exits 0 on BOTH trees — it cannot distinguish the broken tree from the fixed one. The form that reads the axis was one flag away.

NOT MEASURED is not only a reporting rule, it is an inquiry rule. An unmeasured axis is a signal to go find the form that measures it, not a place to stop. The reviewer found this red precisely by taking that one extra step. Added to this seat's dispatch template.

⛔ Why no re-review, established by this seat rather than assumed

The delta from the reviewed head c8d31a3fe0e is two files: the changeset's one word, and five lines of a test header. packages/cli/src/ is byte-identical since the review — the guard the reviewer examined, drove and ablated did not move. ⇒ the review's substantive findings (clause ② yes; guard at the function covering 4 call sites and 5 authoring paths; the no-move property pinned two ways; the inherited-test repair correct) stand on the delivered head without re-verification.

The riders

R2 — two mis-anchored schema citations, corrected in the body, safely. app.zod.ts:300 is BaseNavItemSchema.label (AppSchema.label is :1291); field.zod.ts:299 is SelectOptionSchema.label (FieldSchema.label is :933). ⭐ Editing a 15 KB measured body is how a correct card gets destroyed, so it was done with proof rather than confidence: raw body bytes recovered from the page's embedded edit payload (zero quota, no retyping), md5-verified against the live body before editing, surgical replaces with per-anchor count assertions, then read back and diffed byte-identical. The two anchors that were not flagged were re-measured too, so the check was not selective.

This produced a correction to a card this seat filed 90 minutes ago. FieldSchema.label is z.string().optional(), not z.string()both object-side labels are optional and there is no required-vs-optional asymmetry — the split in #16282 is exactly one dimension, plain string vs I18nLabelSchema. Corrected on that card, together with the admission that this seat republished triage's anchors without re-deriving them.

R3 — a falsifier that did not falsify. The test header named "a label != null guard" as the condition that would break the lowercase rows. ⭐ Not merely reworded — measured both directions: the claimed guard reddens 0 of 5 rows (so it does not falsify), the replacement (typeof label === 'string' inverted) reddens 5 of 5 by name. Each leg proved on disk by grep counts and blob hash, restored under an EXIT INT TERM trap with the restore proven by blob identity and an empty git diff HEAD, ⛔ never by an exit code.

R1 — dissolves; the reviewer's one miss. It flagged 6cd2dc2 as an agent commit lacking the required trailer pair. 6cd2dc2 is a merge commit (Merge remote-tracking branch 'origin/main' …), not an authored change, and AGENTS.md's requirement is on agent-authored commits. Verified by this seat: all three authored commits carry 2/2 trailers. ⇒ nothing to fix and nothing unfixable.

CI — the full population, in the FINAL label state

39 of 39 complete, every one success or skipped, page 2 empty. All three Check Changeset runs green, including the post-strip run at 12:51Z.

⚠️ The count moved 33 → 38 → 39 across this landing, twice while this seat was reading it. Each reading was re-taken rather than carried forward; a population counted before a label write, or before the previous read settled, is not the population being landed.

Pre-squash commit prose

Five commits (three authored, two merges). ⛔ No Fixes/Part of/Closes trailer in any commit bodyFixes #15880 lives in the PR body only. No falsified or stale sentence found.

Clause ②

yes, declared by the implementer against their own interest and confirmed by the review: the mechanical floor does not fire (no export, no payload key, no packages/spec/src/**), but the conformance limb does — a schema-valid config carrying a localized apps[].label or list-view label moves from {"error":"Cannot read properties of undefined (reading 'toUpperCase')"} / exit 1 to {"passed":true,"score":97,"grade":"A"} / exit 0 on a published face.

Both carriers stripped with read-back on each side. Flipping ready and arming. ⛔ Card #15880's pm:dispatched comes off after the merge.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os lint crashes with a bare TypeError on a localized labelcheckLabelCase indexes a value I18nLabelSchema does not require to be a string

2 participants